feat(driver-sql): the read door presents datetime values and audit stamps as canonical ISO-Z text on every dialect (#13973, B1 narrow) - #16619
Conversation
…al ISO-Z text on every dialect The two `if (this.isSqlite)` gates in `formatOutput` around the audit-column repair and the `Field.datetime` fold become unconditional, the audit-column arm gains the `Date` fold the string-only repair never had, `presentReadValue`'s `datetime` arm runs on every dialect, and `readPresentationKind` answers `datetime` for the two builtin audit columns so `aggregate()`/`distinct()` present them too. The pg/mysql2 client parsers are untouched; an Invalid `Date` passes through unchanged. ADR-0053 addendum D-F1..D-F3, ADR-0074 status pointer, anchor, changeset (minor), and the conformance cells: a new per-cell file plus the #13567 and #14078 pins re-pointed at the ruled shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
…nonical-iso-read-door
driver-sql's tsconfig lib predates `at()`; the conformance cell reads the last sorted element by index instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
…iverQuery The query-options erasure ratchet counts an `as any` at a `find|findOne| count|aggregate` argument; the two aggregate queries are on-contract, so they carry the declared `DriverQuery` type instead of an erasure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZNMBg
📓 Docs Drift CheckThis PR changes 1 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d63fe8d728e5e8524160846c1b184b201bdcbce9 && git checkout d63fe8d728e5e8524160846c1b184b201bdcbce9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4fe00b80e7d2da485cae7a777b29609770d8c80b 974731b1c66e96a104766ff078f2a7b38b723411 && git checkout -B drift-repro 4fe00b80e7d2da485cae7a777b29609770d8c80b && git merge --no-ff 974731b1c66e96a104766ff078f2a7b38b723411
node scripts/docs-audit/affected-docs.mjs --json 4fe00b80e7d2da485cae7a777b29609770d8c80b
|
…ough find()'s own presenter; pin the three remaining row doors; qualify the D-F1 headline Contract review of PR #16619 (PASS WITH FINDINGS) named three things to fix before the governed diff reaches a maintainer. FINDING-1: ADR-0053 D-F1, its status line and the conformance file's docblock stated the read-door invariant absolutely while the same addendum carves two exceptions out of it (D-F3's Invalid `Date`; `findWithWindowFunctions`, #16609). Each headline sentence now carries its own carve-outs, agreeing with the anchor invariant that already did. FINDING-2: D-F1 said "asserts it per cell" over seven row doors; the file asserted four. `upsert()`, `bulkUpdate()` and `bulkCreate()` returns are now cells (§A5–§A7) on a second fixture table, with §0's guard carried inside `expectCanonicalInstant`; `bulkCreate()` asserts over the rows a dialect's bulk insert returns and reads the batch back through `find()` on every dialect, so the cell measures something where the dialect has no RETURNING. FINDING-3: `readPresentationKind` routed `created_at` / `updated_at` to the `datetime` kind, so `aggregate()` / `distinct()` folded a number to ISO text where `find()` (ADR-0074 §3) passes it through — an author-declared `created_at: number` read `1700000000000` off `find()` and `"2023-11-14T22:13:20.000Z"` off `distinct()`. A new `audit_timestamp` kind routes those columns to `presentAuditTimestampOutput`, the presenter `formatOutput` itself calls, so both doors share one presenter per column class; `presentReadValue`'s docblock now says exactly where a row walk's composition is and is not replicated. §D pins the agreement on SQLite, the only dialect whose audit column can hold a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…nonical-iso-read-door
…nonical-iso-read-door
…ergence's real reach The sentence replacing the retired "exactly the way formatOutput presents it" claim said the two presenters differ only on a numeric-looking TEXT in a hand-made TEXT-affinity audit column. The delta contract review of PR #16619 reproduced the divergence through the driver's own write door on the driver's own DDL (author-declared non-temporal created_at, `'0x10'` → find() 16, distinct()/max() '0x10'; same for '0b101', '0o17', ' Infinity'). The docblock now says what is true: any TEXT that Number() accepts but SQLite's NUMERIC affinity leaves as TEXT, reachable through create()/update(). No behaviour changes; the shape is outside the B1 ruling's column classes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…FROM/TO table Contract-review patch round on PR #16716 (findings F2, F3, F5 and the non-governed half of F4). No production code changes. F5 — merged origin/main, so this branch now carries #16619: `formatOutput`'s instant gates are unconditional, which is the presenter this door actually ships through. Every CI leg on the previous head measured the pre-B1 presenter. F3 — `sql-driver-window-function-output.test.ts` gains a `measure(cell)` arm over `DIALECT_CELLS`, declared through `declareDialectCell` so an unprovisioned cell is a NAMED SKIP and never a silent pass. It asserts the two halves the SQLite-only arm cannot: `typeof row.ok === 'boolean'` (the MySQL half of the `isSqlite || isMysql` boolean gate) and the canonical `YYYY-MM-DDTHH:MM:SS.sssZ` text for `closed_at` / `created_at` / `updated_at` (the PG + MySQL instant fold). SS4 reads the same row back through raw knex to prove the fold is the driver's and not the client's. F2 — the changeset gains a per-class, per-dialect FROM/TO table covering all seven classes this door moves: adds `external.columnMap` (remote column key -> local field key, every dialect), the SQLite numeric-string -> `number` move, the MySQL `Field.date` `Date` -> `YYYY-MM-DD` move and `Field.time` -> canonical `HH:MM:SS[.fff]`, and spells the instant TO as the canonical text on every dialect. `minor`, the BREAKING banner and the ADR-0087 disposition are unchanged. F4 (non-governed half) — the header comment of `sql-driver-13973-canonical-iso-read-door.test.ts` said this door applies no read presentation. It routes through `formatOutput` since #16609, so the comment now says that and flags that ADR-0053 D-F1 still records it as not covered, with governed docs-only card #16782 carrying the amendment. `docs/adr/**` is untouched here. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH
…t's timestamp passes Since #13973 / PR #16619 (ADR-0053 D-F1) both of `SqlDriver#formatOutput`'s timestamp passes -- the AUDIT_TIMESTAMP_COLUMNS pass and the normalizeSqliteDatetimeOutput pass over datetimeFields -- run on every dialect. Seven out-of-package comments still described them as gated on `if (this.isSqlite)`. The rest-server.ts one was not merely stale: it warned future authors that "a declared Field.datetime is therefore NOT protected on Postgres/MySQL", which invites the tolerant consumer-side coercion ADR-0053 / #16619 expressly forbid. Corrected first, per the triage ruling on the card. Both carve-outs are preserved rather than flattened: - withPostgresCalendarDayAsText is untouched (D-F2) -- the client still hands back a Date; only "nothing folds it" was false. - the Invalid `Date` residue still stands (D-F3) -- no sentence claims the read door never hands out a Date. Comments only; no runtime behaviour changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Fixes #13973
Clause-②: yes
What this lands — the B1 (narrow) ruling, verbatim from comment 5507803003
Maintainer provenance on the card: 「同意」 (2026-09-02, director seat summon #8). Governed surface in the diff (
docs/adr/**) ⇒ this PR stays draft and lands by a human's hand; the ordinary ready → auto-merge → queue path does not apply.The change, by symbol (
packages/drivers/driver-sql/src/sql-driver.ts)formatOutput, audit loop overAUDIT_TIMESTAMP_COLUMNSif (this.isSqlite);repairNaiveUtcAuditTimestampis string-only, so a Postgres/MySQLDatepassed through untouchedpresentAuditTimestampOutput=Datearm (isoFromValidDate) + the unchanged ADR-0074 string repairformatOutput,datetimeFieldsloopif (this.isSqlite)normalizeSqliteDatetimeOutput's existingDatearm now routes throughisoFromValidDatereadPresentationKind'audit_timestamp'kind forcreated_at/updated_at(unless the author declared them temporal, where the temporal kind wins asformatOutput's datetime fold runs last)presentReadValue,'datetime'casethis.isSqlite ? normalize… : valuenormalizeSqliteDatetimeOutput(value)presentReadValue, new'audit_timestamp'casepresentAuditTimestampOutput(value)— the same presenterformatOutputcalls for those two columns, soaggregate()/distinct()andfind()share one presenter per column class (patch round, FINDING-3 below)withPostgresCalendarDayAsTextdocblockField.datetimedepends on it"The JSON, numeric and boolean read repairs keep their dialect gates exactly as they were. The
pgandmysql2client parsers are not touched — §C of the new conformance file proves a raw knex read still hands the driver aDateon both live cells while the read door hands out text for the same row.One mechanism finding against the ruling's own text. The ruling says the two gates "become unconditional (the latter already carries an
instanceof Datearm)". The former —repairNaiveUtcAuditTimestamp— is string-only by contract (if (typeof value !== 'string') return value;), so flipping its gate alone leaves Postgres' and MySQL'sDateexactly where it was. The audit loop therefore gets aDatearm of the same spelling (presentAuditTimestampOutput); without it the ablation below stays red on the audit column.The NaN-guard totality, re-derived at the driver's own boundary (PM Zone 2). The five ruled-B arms PR #16427 landed live in CONSUMERS (
protocol.ts,sys-metadata-repository.ts×2,rest-server.ts×2,database-loader.ts×2), not in the driver, so they do not make the driver's conversion total by themselves. What does is the driver's ownDatearm:isoFromValidDatereturns an InvalidDateunchanged (Number.isNaN(getTime())), the same spelling asisoFromValidDatein the ruled-B family, so the fold never throws — total in the sense #14078 ruled. The consequence is stated rather than hidden: for the one shape #14078 measured (a MySQL zeroDATETIME; a Postgres year in 275760..294276) the read door still hands out the client's InvalidDate, and the ruled-B consumer arms absorb it. ADR-0053 D-F3 declares exactly that;sql-driver-14078-invalid-date-materialisation.test.ts§B2/§B3 keep pinning it on both live dialects. The alternatives (fold tonull; fold to the textInvalid Date) are a decision the ruling did not make and are listed in the report'sopen_questions.The driver comment's dependency claim — falsified on the tree. "
Field.datetimedepends on [theDatematerialisation]": before this changeformatOutputnever touched a datetime column on Postgres/MySQL (the fold was SQLite-gated), so nothing on the read path consumed theDate; every in-repo reader of the value (the class (a) sites, thenew Date(v)wrappers in this package's own live tests, the ruled-B arms) already accepts the text form; and the full driver-sql suite in CI's live shape is green with the fold unconditional (below). The comment is rewritten to say what was checked.Interaction with PR #16579 (same file, earlier today): none. It touched
formatDuplicateGroups(lines ~4213 and its three call sites in the hash-shadow duplicate render); this PR touches the read-presentation region 12 000 lines away. The merge oforigin/main(7 commits since the branch point) was clean and none of them touchedsql-driver.ts.Patch round — the contract review's three findings (comment 5572260375, PASS WITH FINDINGS)
The B1-narrow ruling was found landed correctly and is not re-shaped here; the three findings below are what a maintainer would otherwise read wrong or inherit. Commit
536a172913, thenorigin/mainmerged (f9591b98b6).FINDING-1 — the headline sentences stated an absolute invariant the same addendum carves two exceptions out of. Four sentences said, in effect, "a read door never hands out a JS
Datefor these columns" while D-F3 (an InvalidDateleaves as aDate) and D-F1's own "Not covered:findWithWindowFunctions" (#16609) say otherwise. Each headline now carries its own carve-outs, in the words the anchor invariant already used: the ADR-0053 status line ("every@objectstack/driver-sqlrecord read door butfindWithWindowFunctions(#16609) … those doors never hand out a JSDatefor those columns, save an InvalidDate, which has no canonical text and passes through unchanged"); D-F1's opening ("every record read door … listed here") and its invariant sentence ("None of these doors hands out a JSDatefor these columns, save the one shape D-F3 names …findWithWindowFunctionsis not one of these doors"); and the conformance file's docblock (same two carve-outs, with where each is pinned). The contract is not weakened and the exceptions are not deleted;node scripts/check-adr-anchors.mjsandnode scripts/check-adr-links.mjsboth exit 0 after the edits.FINDING-2 — "declared = enforced" was short by three doors. Option taken: add the cells (not narrow the sentence). §A5
upsert()(merged onto an existing row, and inserted), §A6bulkUpdate(), §A7bulkCreate(), on a second fixture table so their writes cannot move what §B1/§B3 compare against. §0's guard (defined, non-null) is carried insideexpectCanonicalInstant, so a door returning a row without its audit columns fails rather than passing over nothing.upsert()andbulkUpdate()read the row back after their statement on every dialect, so the guard applies unqualified.bulkCreate()mirrors §A3's honesty: it asserts over the rows a dialect's bulk insert returns (all rows or none — a door that dropped part of a batch cannot pass as "no RETURNING"), and its always-non-vacuous leg reads the batch back throughfind()and holds the return equal to the row value for value where a row came back. Which columns a dialect returns is not pinned. D-F1's enforcement paragraph now says exactly this. Measured: 3 doors × 3 cells = 9 new cells, green on SQLite, live Postgres and live MySQL.FINDING-3 — a reproducible
find()vsaggregate()/distinct()divergence this PR introduced. Option taken: (1) one shared presenter, not (2) a gate on the declaration. Mechanism, on the tree:readPresentationKind's added audit arm answered'datetime', so the two doors folded a number to ISO text wherefind()'spresentAuditTimestampOutputpasses it through (ADR-0074 §3); option (2) would have left the second reachable shape — an epoch INTEGER raw-written into the undeclared audit column — divergent, because the undeclared column would still take the datetime fold at these doors and the audit presenter onfind(). So: a newReadPresentationKindmember'audit_timestamp', answered forcreated_at/updated_atafter the temporal check (a declared temporal audit column takes the temporal kind, exactly asformatOutput's datetime fold runs after its audit fold), andpresentReadValueroutes it topresentAuditTimestampOutput— the functionformatOutputitself calls.presentReadValue's docblock no longer claims "exactly the wayformatOutputpresents it"; it says what is true: one presenter per kind, the presenterformatOutputcalls, and the one place a row walk's composition is not replicated (the SQLite numeric repair before the audit presenter on a declared-numberaudit column) — where the two differ on any TEXT thatNumber()accepts but SQLite's NUMERIC affinity leaves as TEXT (hex, binary and octal literals, andInfinity), reachable throughcreate()/update()on the driver's own DDL with an author-declared non-temporal audit column; the delta review reproduced it ('0x10'→find()16,distinct()/max()'0x10') after the first cut of that sentence had claimed the residue needed a hand-made TEXT-affinity column — corrected in974731b1c6, behaviour untouched (the shape lies outside the B1 ruling's column classes; whether to close it is in the report'sopen_questions). Pinned as §D on the SQLite cell only: D1 the review's shape (created_at: number,1700000000000), D2 a raw-written epoch INTEGER in both undeclared audit columns, D3 the control (a legacy zone-naive audit string still folds to ISO at these doors, so the number agreement was not bought by losing ADR-0074's repair). SQLite is the whole coverage and not a shortfall: the DDL never types the audit column from the declaration (builtinColumnsskipped,createAuditTimestampColumnruns), so on Postgres it is atimestamptzand on MySQL aDATETIME(3)— neither can hold a number, and the write that would put one there is refused — SQLite's type affinity is what makes the shape reachable at all. Changeset unchanged: its sentence "now present exactly whatfind()presents" was the claim; the fix is what makes it true.Ablation for FINDING-3 is under Evidence. Not touched, each for the review's stated reason: the seven out-of-package
normalizeSqliteDatetimeOutputcomments (a follow-up card, not a rider), #13993–#13999 and the five ruled-B arms,findWithWindowFunctions(#16609), the Invalid-Datefold options (a maintainer decision, left inopen_questions). One consequence for a file the review called CORRECT, noted rather than acted on: the anchor JSON's invariant names "presentReadValue'sdatetimearm" — still true (that arm exists and runs on every dialect), now incomplete by one arm name; the optional one-line precision is in the report'sopen_questions.Step one — the consumer census (re-runnable)
Consumers that call a
Datemethod directly on a record field fail loudly under B1. Three expressions, each with a positive control, run from the repo root atb4abb0a91and re-checked on the merged head:E1 — named timestamp fields followed by a
Datemethod (positive control: the bare\.toISOString\(count over the same globs is 733):7 hits, none a driver-row consumer that breaks:
platform-objects/…/system-overview-tile-semantics.test.ts:220(in-memory fixture objects),client/src/return-type-precision.test.ts:770(the SDK's own DTO type, wire-fed),metadata-protocol/src/protocol-14078-…test.ts:9(a comment),plugin-auth/src/scim-connection-service.ts:159(input.expiresAt, a caller-suppliedDate, not a read),metadata-protocol/src/protocol.ts:8654-8655(guarded byinstanceof Date— the ruled-B arm, now a no-op on driver rows),plugin-auth/src/session-tombstone.test.ts:427(aDatethe code WRITES, read off a mock's patch).E2 — any receiver, any field, then a
Datemethod, non-test source only (sameM;new Date(,Date.now,Date.UTC,process.andthis.clock/nowreceivers excluded): 6 hits — thescim-connection-service.ts:159andprotocol.ts:8654-8655sites above,create-objectstack/src/created-summary.ts:221-222(Number.prototype.toLocaleString),metadata/src/loaders/filesystem-loader.ts:336(fs.Stats.mtime). None reads a driver row.E3 —
as Date/ angle-bracketDatecasts in non-test source: 0 hits; positive control: the same expression over*.test.tsin driver-sql hits the #13567 / #14078 pins (4 and 3as Datesites on the pre-change tree).E4 — the empirical census inside the only package that sees a live dialect: the whole driver-sql suite in CI's live shape (
TZ=America/New_York, both live cells,OS_EXPECT_LIVE_DIALECT_MATRIX=1) — every pin that asserted the oldDateside is listed under "repaired" below; nothing else went red.Repaired in this PR: the three driver-sql pins that asserted the
Dateside (#13567 §B1–§B4, #14078 §B1, and the #11389 comment restating the falsified dependency). Filed: none needed — no non-test consumer calls aDatemethod on a driver-row field. The seven class (c) site cards (#13993–#13999) and the five ruled-B arms are untouched, per the ruling: under B1 their canonicalisations become no-ops, not conflicts.Contract line
docs/adr/0053-date-and-datetime-semantics.md, status line + a new addendum): D-F1 the read door returnsdatetimevalues and the audit stamps as the canonical ISO-Z text on every dialect (declared = enforced, cells named); D-F2 folded at the driver's read boundary, client parsers untouched; D-F3 the InvalidDateresidue passes through. ADR-0074 gets a one-line status pointer (its read repair is no longer SQLite-only in effect). Thescripts/adr-anchors/…sql-driver.ts.jsonanchor gains ADR-0053 with the invariant in its failure text.sql-driver-13973-canonical-iso-read-door.test.ts(new): per cell of the D-A3 driver axis,find/findOne/update-return/create-return (§A1–§A3),upsert/bulkUpdate/bulkCreatereturns (§A5–§A7, patch round),aggregate(min/maxover the declared datetime AND the audit columns, a raw temporal group key),distinctover all three, theField.datecontrol untouched, §C the raw-knex read proving the fold is the driver's, and §D (SQLite) the one-presenter agreement betweenfind(),distinct()andaggregate()on the audit columns.sql-driver-13567-…§B re-pinned to the ruled shape on every cell (its §A, the record of why aDateat the door was a defect, is kept).sql-driver-14078-…§B1 control re-pinned to text; §B2/§B3 (the InvalidDateresidue) unchanged.minorfor@objectstack/driver-sql, naming the consumer-visible change (Postgres/MySQL in-process consumers receive text where they received aDate; the wire is unchanged).Evidence
Everything below ran in the worktree on the branch; every exit code was captured after a redirect, never through a pipe, and every gate verdict quoted is the gate's own line. First round's union at
c8fe535616; patch round's union atf9591b98b6(the fix536a172913+ the merge of 20origin/maincommits, none touching this PR's driver, test or ADR files); docblock correction atgit rev-parse --short HEAD=974731b1c6(comment-only,974731b1c6on top of a clean merge of 3 moreorigin/maincommits48f1ad8a97; at that head the four touched files on all three live cellsTest Files 4 passed (4),Tests 111 passed (111), driver-sql typecheck 0 errors,check-adr-anchors/check-adr-linksexit 0, gate union re-derived and re-run: "Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN."; no ablation, none is owed for a comment).Patch round, on a fresh local Postgres 16.13 (
timezone=Asia/Shanghai, :54331) and MySQL 8.0.46 (default-time-zone=+08:00, :33073), processTZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1— dependency closure rebuilt after the merge (pnpm --filter '@objectstack/driver-sql^...' build, lockVERDICT command-exit 0):Test Files 4 passed (4),Tests 99 passed (99).f9591b98b6:Test Files 4 passed (4),Tests 111 passed (111)— +12 = the 9 door cells (3 doors × 3 cells) + §D1–§D3, lockVERDICT command-exit 0.readPresentationKind's audit arm answering'datetime'again (anchor counts before: fixed 1 / mutant 0; after: fixed 0 / mutant 1; on-disk blobcaec12253e…≠ HEAD blob027b83b077…). Predicted: red on §D1/§D2'sdistinct()/aggregate()legs and §D3'stoContain(EPOCH)leg, green on every §A/§B/§C cell of every dialect. Observed, the conformance file on the full matrix:Tests 3 failed | 36 passed (39), exactly §D1, §D2, §D3, e.g.distinct(created_at) disagrees with find(): expected [ '2023-11-14T22:13:20.000Z', … ] to deeply equal [ 1700000000000, 1700000000001 ]— the reviewer's reproduction, verbatim direction. Restore bygit checkout HEAD -- ABS_PATH: on-disk blob027b83b077…=HEAD:…sql-driver.ts,git diff HEADempty,git status --porcelainempty for the path, anchor counts back to fixed 1 / mutant 0; re-runTests 39 passed (39). Stale build ruled out two ways, both printed by the script: this worktree never built driver-sql itself (nopackages/drivers/driver-sql/distexists), and the tests import./index.js, which vitest resolves tosrc/index.ts(the pairingscripts/check-test-source-alias.mjspins) — the mutated bytes are what ran.f9591b98b6:pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles— 0 errors, the conformance file in the program (1--listFileshit).f9591b98b6:--print-configresolves for both touched source files (exit 0),--format jsoncounts 2 files / 0 errors / 0 warnings, andeslint.config.mjsstates no type-aware linting for any file (noparserOptions.project, no typed rule), so this diff cannot move an untouched file's verdict.f9591b98b6, derived from the merge-base changeset withnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(no hand-listed paths; 72 families), each exit code captured after a redirect, reconciled with--ran: "Run reconciliation — 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN." 69 of 72 exit 0 (check:adr-anchors,check:adr-links,check:adr-symbol-anchors,check:query-options-erasure,check:test-source-alias,check:type-check-coverage,check:dts-closure,check:nul-bytesamong them). NOT MEASURED, exit 3 each, in the gates' own words:check:type-check-debt("check-type-check-coverage: PREREQUISITE NOT MET"),check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/"),check:doc-formula-expressions("PREREQUISITE NOT MET — the workspace package@objectstack/lintis not built … Nothing was measured"). The cause is the unbuilt dependency closure, not memory (the seat measuredcheck:type-check-debtat 8192 MB with the same exit 3); none was chased with a workspace build.First round's evidence, at
c8fe535616, kept as recorded:Live cells, locally, on a real Postgres 16 and MySQL 8.0. The container carries the server binaries, so both were provisioned the way CI's job does (PG
timezone=Asia/Shanghaion :54329, MySQLdefault-time-zone=+08:00on :33071, processTZ=America/New_York,OS_EXPECT_LIVE_DIALECT_MATRIX=1so a missing cell is red, not skipped); the testkit's three-way zone skew guard passed on both cells. Data dirs under/tmp/os13973-live, torn down after the run.updated_at instanceof Dateon both live cells):Test Files 2 passed (2),Tests 29 passed (29).Test Files 167 passed (167),Tests 3623 passed | 1 skipped (3624), lockVERDICT command-exit 0(the one skip is pre-existing and not in a touched file).Test Files 3 passed (3),Tests 60 passed (60).Firing control (reverse verification), committed fix first, both legs traced.
sql-driver.tsrestored to its BASE blob (91337b2b…, hash-verified on disk;presentAuditTimestampOutputcount 0, the oldthis.isSqlite ? … : valuearm count 1), the three conformance files run on the live matrix:Tests 24 failed, every failure on thelive postgres/live mysqlcells — the new file's §A1–§A3, §B1–§B3 and §C, #13567's §B1–§B4, #14078's §B1 — and every SQLite cell green. Predicted direction was red on §A/§B and green on §C; §C reddened too, because its equality leg compares the rawDate's ISO text against the presented value, which under the old gate is itself aDate— reported as observed. Restore bygit checkout HEAD -- ABS_PATH: on-disk blob08d23fe4…=HEAD:…sql-driver.ts,git diff HEADempty,git status --porcelainempty for the path; re-runTest Files 3 passed (3),Tests 60 passed (60). No build sits between the mutation and the run: driver-sql's tests import./index.js, resolved by vitest tosrc/index.ts(the pairingscripts/check-test-source-alias.mjspins), so the mutated bytes are what ran — stated, not skipped.Typecheck.
pnpm --filter @objectstack/driver-sql exec tsc --noEmit --listFiles: 0 errors at the final HEAD, and the new test file is present in the program (1--listFileshit; driver-sql's tsconfig has no test exclusion) — so the typecheck genuinely covers it. A first pass caught twoArray.prototype.atuses (TS2550, lib target), fixed.Lint. Full repo scan
pnpm lint(eslint . --no-inline-config): exit 0. Also the narrowed form with its three pieces of evidence: population from eslint's own config (--print-configresolves for all 5 touched files, exit 0),--format jsoncounts 5 files / 0 errors / 0 warnings, and type-aware linting is not enabled (eslint.config.mjssets noparserOptions.projectand no typed rule), so this diff cannot move any untouched file's verdict.Gates. Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsaftergit fetch origin mainand the merge (72 families; identical list before and after the merge). Each run with its exit code written to a TSV; union re-run at the final HEADc8fe535616; reconciled with--ran: "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".check:adr-anchors(with the new ADR-0053 invariant),check:adr-links,check:adr-symbol-anchors,check:query-options-erasure(a first pass grew the test surface 236 → 238 through twoas anyaggregate queries in the new file; they are on-contract and now carry the declaredDriverQuerytype — ratchet back to green, not widened),check:type-check-coverage,check:dts-closure(green after removing a half-builtpackages/lint/distmy own single-package build attempt had left; that artefact is not in the diff).check:type-check-debt("PREREQUISITE NOT MET … measuring now would … silently measure a DIFFERENT WORLD"),check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/"),check:doc-formula-expressions("PREREQUISITE NOT MET —@objectstack/lintis installed, but something it imports is not":@objectstack/sdui-parserunbuilt). All three need the whole-workspace build CI performs; per the PM's correction none was chased with a workspace build here.Clause-②: yes — a published driver's read-door output shape changes on two dialects.
needs:contract-reviewhung on the card and the PR together with this PR's opening.维护者速读(草稿)
改了什么:
@objectstack/driver-sql的读门(find/findOne/create、update、upsert、bulkCreate、bulkUpdate的返回行,以及aggregate的min/max与distinct)在 Postgres 和 MySQL 上,对内建的created_at/updated_at和所有声明为datetime的字段,改为交出与 SQLite 一致的规范 ISO 文本(2026-09-07T12:00:00.123Z),不再交出 JSDate对象。数据库客户端解析器一处未动;驱动在自己的读边界折叠。aggregate/distinct对两个审计列走的是find()同一个呈现函数,三个门对同一列的答案逐值一致。ADR-0053 追加 D-F1..D-F3(状态行与 D-F1 的不变量句自带两个例外:无法折叠的 InvalidDate原样交出;findWithWindowFunctions不在覆盖面内,已立 #16609),ADR-0074 加一行指针,changesetminor。为什么改:同一个「最后更新时间」在 SQLite 上是文本、在生产默认的 Postgres 上是
Date,44 个读库的包里 43 个只在文本侧测过,这个类已经出过 8 次事故(线上 1 次,普查读代码 7 次),方向全部是「期望文本、拿到 Date」,反向零次。这是您 2026-09-02 对 #13973 拍的 B1 收窄版(「同意」),本 PR 是落地。风险与代价(含回滚):Postgres / MySQL 上的进程内消费者从此拿到字符串而不是
Date;直接对记录字段调.getTime()一类方法的代码会响亮报错——本 PR 的普查在非测试源码里一处都没找到;经 HTTP/JSON 的调用方看不到任何变化(JSON.stringify本来就把Date序列化成同一段文本)。无法折叠的坏值(MySQL 零值日期、Postgres 超出 JS 范围的年份)原样交出 InvalidDate,与 #14078 的裁决一致。回滚:revert 本 PR 即可,无数据迁移、无存储变更;本地实测 Postgres 16 + MySQL 8.0 全绿(167 个文件 3623 个用例)。席位意见:(留空)
你要做的:确认 ADR-0053 的 D-F1..D-F3 与您的裁决一致(状态行与 D-F1 现在是完整的一句话,不必翻到 D-F3 才知道它有例外),然后人工合并(受管面,不走队列)。若您希望坏值改交
null或文本Invalid Date,报告的open_questions列了两个选项,可另行裁定。验收备注
String(row.created_at) >= windowStartcompares aDate.toString()against ISO text #13993–os migrate duplicatesreports each holder'screatedAtas aDate.toString()spelling on Postgres/MySQL #13999) and the five ruled-B arms (The shared canonical-ISO normaliser turns an InvalidDatefrom a driver into a 500, whereString()served text #14078) are now no-ops on driver rows; their docblocks still describe the pre-B1 gate. Retiring or refreshing them is separate, deliberate work — the ruling says do not touch them here.normalizeSqliteDatetimeOutputwhile describing the old SQLite-only gate (sys-metadata-repository.ts,rest-server.ts,database-loader.tsand their tests); the function keeps its name (the ruling names it), its docblock now says it runs on every dialect.findWithWindowFunctionsreturns storage forms — a declared boolean answers1and an object field answers JSON text wherefind()answerstrueand the parsed object #16609:findWithWindowFunctionsapplies no read presentation at all (a declared boolean answers1, an object answers JSON text on SQLite — reproduced from the built package). Out of the ruling's surface; ADR-0053 D-F names it as not covered. driver-sql:findWithWindowFunctionsreturns storage forms — a declared boolean answers1and an object field answers JSON text wherefind()answerstrueand the parsed object #16609 is not addressed here./tmp/os13973-live(first round) and/tmp/os13973-r2-live(patch round) — the scratchpad's ancestors are root-only and thepostgres/mysqlservice users cannot traverse them — and are torn down with the worktree.normalizeSqliteDatetimeOutputcomments want a follow-up card (the reviewer's point that "noted, not filed" leaves nothing queryable is taken); the card is the PM's to file, not a rider here.presentReadValuedoes not replicateformatOutput's composition of the SQLite numeric repair before the audit presenter on an author-declarednumberaudit column — the two differ on any TEXT thatNumber()accepts but SQLite's NUMERIC affinity leaves as TEXT ('0x10','0b101','0o17','Infinity'), reachable throughcreate()/update()on the driver's own DDL (delta review's reproduction). Recorded in the docblock; behaviour deliberately not changed — the shape is outside the B1 ruling's column classes and closing it is a decision the ruling did not make.